CI・CD界隈期待の星!!Daggerに入門してローカルとGithubActionsでCIを動かしてみた
こんにちは、AWS事業本部コンサルティング部に所属している今泉(@bun76235104)です。
みなさん、CI・CDのプラットフォームは何を利用されていますか?
- AWS CodePipeline
- AWS CodeDeploy
- AWS CodeBuild
- GitHub Actions
- CirleCI
- GitLab CI/CD
など、沢山の魅力的なサービスがありますね。
一方で以下のような悩みを抱えていらっしゃいませんか?
- CI/CDサービスの移行が必要になり、設定ファイルを大きく書き換える必要がある
- ↑が大変だったので、SaaSの製品を使うようにしたら割と費用がかかるようになった
- CI/CDの設定をyamlで書いているが、ローカルでの開発が大変
- 書いた設定ファイルをCI/CDのサービスに適用するまで、正しい動作をしているかわからない
私がまさにそのような経験をしたことがあります。
どんなサービスを使っても良いように、シェルスクリプトなどで処理を記載して各種サービス上で実行するだけにする(シェルでラップする)というアプローチもあるのですが、「あー・・・GithubActionsの○○っていうアクションを使えば数行で終わるのに、ゴリゴリcliを手動で書いているなぁ・・・・」という気持ちになったこともありました。
そんな CI/CD迷子になっていた私ですが、Daggerというサービスが発表されたので、Daggerの特徴にちょっと触れつつ、チュートリアルをローカル環境で実施してみました!
なお、Daggerの設定ファイルはCUE言語で記述します。
CUE言語については、何も知らなかったのでCUE言語に入門してみた | DevelopersIOで触れております。
Daggerを触ってみる前に CUEを少し触っておくと理解がスムーズになるかと思いますので、ぜひこの機会に触ってみてください!
2022.12.26追記 各言語でのSDKが公開されたことにより、CUEでの実装は必須ではなくなりました。Node.js でのやってみた記事は以下をご参照ください。
Daggerの何が嬉しいのか
従来のCI/CDでは各種サービスに合わせた設定ファイル(yamlなど)をサービスに渡すことで処理(パイプライン)を実行していました。
これによりなんらかの事情でサービスを移行する際には設定ファイルを移行先のサービスに合わせて大きく書き換えを行う必要がありました。
また、設定ファイルをサービスに渡す(git commit => git push)まで、設定ファイルが意図した動作をするか分かりにくい問題もあったかと思います。 (サービスによってはローカル環境で実行できる仕組みを用意してくれているかと思いますが)
DaggerはDockerの仕組みを利用して、どの環境でもほぼ同じように動作をさせることができます。
下図のように、各種サービスやローカル環境の上でDaggerを動かすイメージです。 (Daggerというレイヤーが増えているような形です)
私もやってみましたが、ローカル環境でうまく動作させることができた処理はすんなりGithubActionsの上に持っていくことができました。
なお、Dagger自体を動かすために各サービスに応じた方法でDaggerを呼び出す必要がありますので、厳密に言えばyamlが不要になるのではなく、必要な処理の多くをDagger上で行うことができるようになるという形です。 (その他認証・認可の処理などは各サービスに任せることもあると思います)
以下はGithubActionsの例ですが、詳しくは公式サイト(Integrating with your CI environment | Dagger)の例をご覧ください
name: todoapp on: push: # Trigger this workflow only on commits pushed to the main branch branches: - main # Dagger plan gets configured via client environment variables env: # This needs to be unique across all of netlify.app APP_NAME: todoapp-dagger-europa NETLIFY_TEAM: dagger jobs: dagger: runs-on: ubuntu-latest steps: - name: Clone repository uses: actions/checkout@v2 # GithubActions用のDaggerのアクションを利用 - name: Deploy to Netlify uses: dagger/dagger-for-github@v3 # See all options at https://github.com/dagger/dagger-for-github with: version: 0.2 # ダガーで記載した処理を行う project update do deploy env: # Get one from https://app.netlify.com/user/applications/personal NETLIFY_TOKEN: ${{ secrets.NETLIFY_TOKEN }}
まずはDaggerを始めてみる
色々やりたいことはありますが、まず公式のBuild & run locally...then in CI | Daggerをやってみました。
Daggerの公式サイトに従って dagger
コマンドを利用できるようにします。
私の場合は Macを利用しているため brew
コマンドでインストールします。
なお、先に docker
コマンドが利用できる必要があるためご注意ください。
brew install dagger/tap/dagger # 確認 type dagger
With Docker running, we are ready to download our example app and run its CI/CD pipeline locally:
Dockerが稼働している状態で、サンプルプロジェクトをローカルで動作させることができるようなので、早速公式サイトに記載されている通りにコマンドを打ち込んでみます
git clone https://github.com/dagger/todoapp cd todoapp dagger do build
ただこの時点で以下のようにエラーが発生しました。
3:10PM ERROR system | failed to load plan: package "dagger.io" is incompatible with this version of dagger (requires 0.2.11 or newer). Run `dagger project update` to resolve this package "dagger.io" is incompatible with this version of dagger (requires 0.2.11 or newer). Run `dagger project update` to resolve this
ということで、素直に表示してくれているコマンドを実行して通るようになりました。
dagger project update # 再度実行 dagger do build
以下のように無事 buildされたようです。
[✔] actions.build.container 43.9s [✔] actions.build.install.container 38.0s [✔] actions.source 0.3s [✔] actions.build.container.script 0.0s [✔] actions.build.install.container.script 0.0s [✔] actions.build.install.container.export 0.0s [✔] actions.build.container.export 0.0s [✔] client.filesystem."./build".write 0.0s
公式に従って open build/index.html
というコマンドを打ち込んでみると以下のようにサンプルアプリケーションが開かれました。
Now that we have everything running locally, let us make a change and get a feel for our local CI/CD loop. The quicker we can close this loop, the quicker we can learn what actually works. With Dagger, we can close this loop locally, without committing and pushing our changes. And since every action is cached, subsequent runs will be quicker.
手元のPCで 自分の作成した CI/CDの設定ファイルの結果が確認できるので
従来のような、設定ファイルを作ってcommitしてpushしたけどCI/CDの動作を見てみると、設定のyamlファイルを間違えていたからまた修正のコミットする ということが少なくなりそうな期待があります。
Daggerのライフサイクルなどは後で見ていこうと思うので、深掘りしませんが、このサンプルプロジェクトの dagger.cue
ファイルを見てみると、dagger do build
で yarn build
を実行しているっぽいことなどがわかります。
package todoapp import ( "dagger.io/dagger" "dagger.io/dagger/core" "universe.dagger.io/netlify" "universe.dagger.io/yarn" ) dagger.#Plan & { actions: { // Load the todoapp source code source: core.#Source & { path: "." exclude: [ "node_modules", "build", "*.cue", "*.md", ".git", ] } // Build todoapp build: yarn.#Script & { name: "build" source: actions.source.output } // Test todoapp test: yarn.#Script & { name: "test" source: actions.source.output // This environment variable disables watch mode // in "react-scripts test". // We don't set it for all commands, because it causes warnings // to be treated as fatal errors. // See https://create-react-app.dev/docs/advanced-configuration container: env: CI: "true" } // Deploy todoapp deploy: netlify.#Deploy & { contents: actions.build.output site: string | *"dagger-todoapp" } } }
Daggerの構成要素・ライフサイクル
DaggerではActionと呼ばれるものが基本の要素となり、以下のようにライフサイクルの中で
- Defintion
- Integration
- Discovery
- Execution
という流れを踏むようです。
Daggerで特徴的だとおもった仕組みが、Daggerでは パイプライン
や steps
という考えがなく、全てActionと定義されている点です。
One consequence of arbitrary nesting is that Dagger doesn't need to distinguish between "pipelines" and "steps": everything is an action. Some actions are just more complex and powerful than others. This is a defining feature of Dagger.
パイプラインの配下にステップを用意して、ステップの中で処理を行うのではなく、あくまでActionがネストしていて、定義済みのアクションを別のアクションからサブアクションとして呼び出せる
という考え方のようですね。個人的には覚えやすいです。
Definiton
公式のDefintionの説明で使われているHelloWolrdの例です。(参考: Dagger Actions | Dagger)
このアクション(#AddHello)の中で、core.#Writefile
という別のアクションを呼び出していることがわかります。
package main import ( "dagger.io/dagger" "dagger.io/dagger/core" ) // Write a greeting to a file, and add it to a directory #AddHello: { // The input directory dir: dagger.#FS // The name of the person to greet name: string | *"world" write: core.#WriteFile & { input: dir path: "hello-\(name).txt" contents: "hello, \(name)!" } // The directory with greeting message added result: write.output }
こちらの例では
入力: dir
name
出力: result
アクション: write
というように#AddHelloを定義しているようです。
これらアクションやフィールドの定義(フィールド名の付け方など)はDaggerの仕様として決まっているわけではなく、CUE言語の構造体を使って自由に定義するもの のようです。
上のように、出力は result
というフィールドを定義する必要があるわけではなく、あくまでこの#AddHello
というアクションではそのように定義しているだけということですね。
Integration
Action definitions cannot be executed directly: they must be integrated into a plan.
上記で定義したようなアクションは直接実行するのではなく、必ず Plan
に組み込む必要があるようです。
先ほどの#Addhello
のアクションをPlanに組み込んでいる例です。(参考: Dagger Actions | Dagger)
package main import ( "dagger.io/dagger" ) dagger.#Plan & { // Say hello by writing to a file actions: hello: #AddHello & { dir: client.filesystem.".".read.contents } client: filesystem: ".": { read: contents: dagger.#FS write: contents: actions.hello.result } }
actionsに#AddHello
のアクションを組み込んでいます(#AddHello
で利用しているサブアクションはPlanに記述しない)
client
ではDaggerを実行しているホストマシンのデータを使用したい場合に記述するようです。(参考: Interacting with the client | Dagger)
今回はホストマシンのファイルシステムにアクセスするためこのように記述しているようです。
Discovery
ここまでPlanを定義しておくことで、Actionがユーザーから見えるようになるようです。
$ dagger do --help Execute a dagger action. # ↓ここで定義したアクションが表示されている Available Actions: hello Say hello by writing to a file Usage: dagger do [OPTIONS] ACTION [SUBACTION...] [flags] Flags: [...]
なお、ローカルで試す際には以下手順を踏みました。
# 初期化 dagger project init dagger project update
ここまでした状態で dagger.cue
ファイルを用意します。
package main import ( "dagger.io/dagger" "dagger.io/dagger/core" ) // Write a greeting to a file, and add it to a directory #AddHello: { // The input directory dir: dagger.#FS // The name of the person to greet name: string | *"world" write: core.#WriteFile & { input: dir path: "hello-\(name).txt" contents: "hello, \(name)!" } // The directory with greeting message added result: write.output } dagger.#Plan & { // Say hello by writing to a file actions: hello: #AddHello & { dir: client.filesystem.".".read.contents } client: filesystem: ".": { read: contents: dagger.#FS write: contents: actions.hello.result } }
この状態で dagger do --help
を行うことで hello
アクションが見つかりました。
Execution
あとは以下のように実行することで正常に動作していることが確認できました。
dagger do hello # 以下のように出力される [✔] client.filesystem.".".read 0.2s [✔] actions.hello.write 0.0s [✔] client.filesystem.".".write 0.1s ls # hello-world.txtが作成されたことを確認できた > hello-world.txt
なお、最初にご紹介した公式のtodoappの例では、cueファイルにて
source
build
test
deploy
というアクションが定義されています。
dagger.#Plan & { actions: { // Load the todoapp source code source: core.#Source & { # 省略 } // Build todoapp build: yarn.#Script & { # 省略 } // Test todoapp test: yarn.#Script & { # 省略 } // Deploy todoapp deploy: netlify.#Deploy & { # 省略 } } }
そのため、公式では dagger do build
意外にも以下のように他のアクションも可能です。
$ dagger do source [✔] actions.source 0.0s
DaggerにはDockerが使われているという先入観から、build
と聞くと docker build
のようなコマンドかと一瞬勘違いしたのですが、単にcueのなかで自身で定義したアクション名だということがわかってスッキリました!
自分でDaggerのCIを定義してみた
ここまでチュートリアルを終えたので、早速自分でCI/CDを組んでみました。
今回Daggerでやる処理内容は以下のシンプルなものです
- AWS Systems Manager Parameter Storeからパラメーターを取得してJSONファイルに出力
- 取得したJSONファイルをPythonスクリプトで読み込んで、
パラメター名=値
の形式に変換して .envファイルとして出力 - 出力した .envファイルをコンソールに出力
- ※ 今回は一連のアクション成功確認のため.envの内容を出力しますが、当然本番環境などで同じことをされないようにご注意ください。
全体のファイル構成
プロジェクト全体のファイル構成は以下のようになっております。
. ├── _scripts #コンテナ内で実行させるスクリプトなどを格納 │ ├── install.sh │ ├── main.py │ ├── output.sh ├── build # アクション(処理)の実行結果を格納 ├── cue.mod # dagger project init で作成されるモジュール群など │ ├── dagger.mod │ ├── dagger.sum │ ├── module.cue │ ├── pkg │ └── usr ├── daggaer.cue # メインのdagger設定ファイル ├── image.cue # アクションで使うDockerイメージなどの定義用の設定ファイル
今回用意したパイプラインの主要なCUEファイルは以下のようになりました。
getParameters
, createEnv
,displayOnConsole
という3つのアクションを定義しております。
package main import ( "dagger.io/dagger" "dagger.io/dagger/core" "universe.dagger.io/aws" "universe.dagger.io/docker" "universe.dagger.io/bash" ) dagger.#Plan & { client: { // IAMロールの一時的なクレデンシャル情報を渡す env: { AWS_ACCESS_KEY_ID: dagger.#Secret AWS_SECRET_ACCESS_KEY: dagger.#Secret AWS_SESSION_TOKEN: dagger.#Secret } // actionsの個々のアクションで読み込ませたいものを read 出力したいファイルを write として記述する filesystem: { "build/parameters.json": write: contents: actions.getParameters.export.files["/parameters.json"] "./": read: contents: dagger.#FS "build/.env": write: contents: actions.createEnv.output.contents } } actions: { // ParameterStoreから取得するアクション getParameters: aws.#Container & { always: true credentials: aws.#Credentials & { accessKeyId: client.env.AWS_ACCESS_KEY_ID secretAccessKey: client.env.AWS_SECRET_ACCESS_KEY sessionToken: client.env.AWS_SESSION_TOKEN } command: { name: "sh" flags: "-c": "aws ssm get-parameters-by-path --path=/ --region=ap-northeast-1 > /parameters.json" } _build: _scripts: core.#Source & { path: "_scripts" } export: files: "/parameters.json": _ } // Pythonスクリプト実行して.envを作成するアクション createEnv: { script: { directory: client.filesystem."./".read.contents filename: "_scripts/main.py" } args: [...string] _mountpoint: "/var/lib/python" // コンテナでPythonを実行するサブアクション run: docker.#Run & { _defaultImage: #Image input: *_defaultImage.output | docker.#Image workdir: _mountpoint always: true command: { name: string | *"python" "args": ["\(_mountpoint)/\(script.filename)"] + args } mounts: "MountFiles": { contents: script.directory dest: _mountpoint } } // コンテナ中のファイルを出力するサブアクション output: core.#ReadFile & { input: run.export.rootfs path: "/.env" } } // 作成した.envの内容をコンソールに出力する displayOnConsole: { run: bash.#RunSimple & { always: true script: { directory: client.filesystem."./".read.contents filename: "_scripts/output.sh" } } } } }
また今回は、PythonのDockerイメージを使って処理を行い、結果をファイルとして出力させたかったので以下のように利用するイメージなどを定義しております。
package main import ( "universe.dagger.io/docker" ) #Image: { // The python version to use version: *"3.10" | string // Whether to use the alpine-based image or not alpine: *true | false docker.#Pull & { *{ alpine: true source: "python:\(version)-alpine" } | { alpine: false source: "python:\(version)" } } }
なお、Daggerで既に用意されているpythonパッケージがありますが、今回はdockerパッケージを利用しています。
dockerパッケージはこちらの export
フィールドで処理結果の出力が分かりやすく、逆にpythonパッケージには出力用のフィールドが定義されていませんでしたので、今回のようにファイルシステムに書き出したファイルの内容を出力するような処理は想定されていないのかと考えたためです
なお、pythonパッケージ内でも内部的にはdockerパッケージを利用しているので同様の方法で出力自体は可能だと思います。(未検証)
Daggerが作成するDockerコンテナから実行させるスクリプト類の準備
_scripts ディレクトリに以下3ファイルを用意します
install.sh
: aws.#Container
アクションでコンテナへの各種コマンドのインストールなどの前準備用に配置する必要があります。
ソースファイルを眺めてみましたが配置されていることが前提で、特にデフォルト処理などは用意されておりませんでした(関連するソースコード)
awscliv2の最新版をインストールするようにしています。
#!/bin/sh ARCH=$(uname -m) curl -s "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}-$1.zip" -o awscliv2.zip unzip awscliv2.zip -x "aws/dist/awscli/examples/*" "aws/dist/docutils/*" ./aws/install rm -rf awscliv2.zip aws /usr/local/aws-cli/v2/*/dist/aws_completer /usr/local/aws-cli/v2/*/dist/awscli/data/ac.index /usr/local/aws-cli/v2/*/dist/awscli/examples
main.py
: createEnv
で呼び出している docker.#Run
サブアクションから実行させるPythonスクリプト。getParametes
アクションの結果のJSONを .envとして展開する
# -*- coding: utf-8 -*- import json from os import path def main(): file_path = path.join('build', 'parameters.json') json_file = open(file_path) param_dic = json.load(json_file) with open('/.env', mode='w') as f: for param in param_dic['Parameters']: key = param['Name'] value = param['Value'] s = '{}={}\n'.format(key, value) f.write(s) if __name__ == '__main__': main()
output.sh
: createEnv
のPythonスクリプトが作成した .envnの内容をコンソールに出力するだけの処理
#!/bin/bash cat bash/scripts/build/.env
また、処理の結果を格納するために _scripts
と同階層に build
というディレクトリを作成しました
mkdir build
Systems Manager Parameter Storeにパラメータを登録
今回のテスト用に2パラメータを登録しました。 (Systems Manager Parameter Storeへの登録手順は省略させていただきます)
JSONで出力すると以下のような値になります
{ "Parameters": [ { "Name": "DAINODAIBOUKEN", "Type": "String", "Value": "SAIKOU", "Version": 1, "DataType": "text" }, { "Name": "TOKYO03", "Type": "String", "Value": "SAITUYO", "Version": 1, "DataType": "text" } ] }
ローカル環境で自作アクションの実行
以下コマンドで、依存したモジュールなどを揃えます。
dagger project init dagger project update
daggerでアクションを実行
dagger do getParameters dagger do createEnv dagger do displayOnConsole
なお、本処理を行う前に dagger
コマンドを行うホストマシンで以下環境変数の設定が必要になります。
AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY # ↓は必須ではありません(筆者はIAMロールの一時的な認証情報で処理を行なったため) # 参考: https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_credentials_temp_use-resources.html AWS_SESSION_TOKEN
正常に処理が終了すると build/.env
に以下のようなファイルが出力されます。
DAINODAIBOUKEN=SAIKOU TOKYO03=SAITUYO
ようやくGithub Actionsで実施してみる
ローカル環境での動作確認ができたので、GithubActionsで処理を走らせてみたいと思います。
以下のようにyamlでGithubActionsの設定を記述します。
name: dagger-get-started on: push: branches: - main # Dagger plan gets configured via client environment variables env: AWS_REGION: ap-northeast-1 AWS_ROLE_ARN: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-get-ssm-parameters jobs: dagger: runs-on: ubuntu-latest permissions: id-token: write contents: write steps: # クレデンシャルはGithubActionsの↓アクションで設定 - name: Configure AWS credentials from IAM Role uses: aws-actions/configure-aws-credentials@v1 with: role-to-assume: ${{ env.AWS_ROLE_ARN }} aws-region: ${{ env.AWS_REGION }} - name: Clone repository uses: actions/checkout@v3 # You need to run `dagger project init` locally before and commit the cue.mod directory to the repository with its contents - name: create dotenv uses: dagger/dagger-for-github@v3 # See all options at https://github.com/dagger/dagger-for-github with: version: 0.2 cmds: | project update do getParameters do createEnv do displayOnConsole env: accessKeyId: client.env.AWS_ACCESS_KEY_ID secretAccessKey: client.env.AWS_SECRET_ACCESS_KEY sessionToken: client.env.AWS_SESSION_TOKEN
なお、Configure AWS credentials from IAM Role
のstepで今回のプロジェクト用に作ったIAMロールの権限を利用しております。
この部分については、こちらの記事をご査証ください。(参考: GitHub ActionsにAWSクレデンシャルを直接設定したくないのでIAMロールを利用したい | DevelopersIO)
参考の記事のとおりIDプロバイダの作成、IAMロールの作成、Github Secrets設定などを行っています。
ここまでで設定した上で、Githubリポジトリの mainブランチにマージすることで、GithubActionsのワークフローが実行されます。
ようやくですが、 Dagger を動かすことができました!!
Daggerで辛かった・イマイチだと思った点をご紹介
忖度せずに、今回実装していて気になった点を述べていきます。
ドキュメントが少ない
2022/7/15 時点でまだ v0.2.23 であり、これからどんどん活発に利用されるようになると思うのですが、現時点ではドキュメント類が少ないです。
また当然、日本語記事も少ないのでかなりソースを読む必要がありました。
たとえば、こちらに公式のパッケージが定義されています。
- alpine
- aws
- bash
- docker
- go
- nginx
- python
- yarn
- など
それぞれ公式のドキュメントを見ても使い方はほとんど書かれていませんので、それぞれのパッケージのソースを読む必要があります。
それぞれのパッケージの入出力・型情報もこちらのパッケージを見る必要があります。
たとえば、私は docker
パッケージの出力がよくわからなかったので、このあたりのソースを読んで、望む出力結果を得る方法を考えました。
依存モジュールの取り扱いがよくわからなかった
Daggerでは dagger project init
のコマンドにより依存モジュール群が cue.mod
ディレクトリに出力されます。
私は Node.js の node_modules
の要領で、これらは .gitignore
でgit管理から除外して、Daggerを実行する各ホストマシンで依存モジュール群を取得するのかとおもっていました。
しかしDagger公式(Integrating with your CI environment | Dagger)のGithubActions設定用の yaml
の例では、以下のように事前に dagger project init
をローカル環境で実行して 取得したパッケージ群をcommitしてgit管理化に置いた上で、各ホストマシン上では dagger project update
を実行して依存モジュールの更新をするように示唆しております。
You need to run
dagger project init
locally before and commit the cue.mod directory to the repository with its contents
cue.mod
には数百ファイルが含まれているため、この辺をプロジェクトごとにGit管理するのに少し違和感を覚えました。
npm
や deno
のパッケージ管理の考えしか知らなかったため、困惑しているのだと思います。
これから、この辺りの仕組みも整備されていくのかもしれません。
CUE言語の学習コストがかかる
DaggerではCUE言語を設定ファイルに使っているため、当然CUE言語の学習コストがかかります。 (基本を抑えるだけならそんなに重いコストではないと思います)
また、CUE自体のこれからの広がり方次第でDaggerを扱えるエンジニアが限られてくるという一面があるのかと思いました。
最後にDaggerを使ってみて感じたこと
上記のような課題はありますが、逆に良い点もたくさん感じました。
- ローカル環境での開発・テストが容易
- 各CI/CDサービス側で設定することが少ない
- 今回以下の処理はGithubActions側で行いましたが、Dagger側に寄せて良さそう
- AWS権限周りのための設定
- ソースコードを取得
- Dockerを使っているので汎用性が高く、パッケージや自作アクションの広がりが期待できそう
- GithubActionsみたいに色々定義されたら便利そう
総じて言えるのは まだまだ発展途上ではあるからこそ今後に期待できそうという感じです。
みなさんも是非一度使ってみてください!!